Skip to content

Fix web browser-history sync (WasmJS) + switch Json discriminator mode to POLYMORPHIC#220

Merged
isaac-udy merged 19 commits into
mainfrom
fix/web-history-sync
Jun 11, 2026
Merged

Fix web browser-history sync (WasmJS) + switch Json discriminator mode to POLYMORPHIC#220
isaac-udy merged 19 commits into
mainfrom
fix/web-history-sync

Conversation

@isaac-udy

@isaac-udy isaac-udy commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Summary

Two related fixes discovered while debugging jumpy/broken browser back-navigation in a production WasmJS app (reglyph), plus the test coverage that pins them.

1. WebHistoryPlugin sync rewrite

The history synchronisation had three concurrency bugs:

  • Dropped updates. Events arriving while a sync job was in flight were silently discarded, desyncing the in-memory history mirror from the real session history — after which a single browser back traversed multiple app screens. All lifecycle callbacks and popstate events now feed a single serial processor (with per-event exception isolation, so one failing sync can't kill history handling for the session).
  • history.go() races / echo double-backs. Traversals were followed by delay(1) and time-based listener disabling; the traversal's own popstate echo usually arrived after suppression lifted and was processed as a second user back. Self-initiated traversals now await and explicitly consume their popstate echo.
  • Replacements pushed as forward navigation. Any state not seen before was unconditionally pushState-ed, so full-backstack replacements (loading gate → home, section truncate-and-open) accreted stale browser entries — making transient screens (e.g. a startup/loading destination) reachable via back. The previously-dead isSubset helper is now wired up: push only when the previous state is a prefix of the new one, otherwise replaceState.

Also: bounded step-past loop instead of the blind history.back() retry; listener/processor cleanup on detach; URL fallback — entries whose recorded state can't be decoded (tab history survives app deploys) restore through their URL via the @NavigationPath bindings and self-heal the entry's stored state; and write-time round-trip verification — a state that can't restore is a hard error at write time, never a silently dead back button later.

2. jsonConfiguration: ALL_JSON_OBJECTS → default POLYMORPHIC mode

The verification above immediately caught silent state corruption, which turned out to be upstream kotlinx (1.11.0) bugs in both Json encoders under ClassDiscriminatorMode.ALL_JSON_OBJECTS with polymorphic dispatch:

Shape in the polymorphic class Streaming encoder Tree encoder
value-class field deferred discriminator leaks into the next-opened object (corrupted Instance.metadata, undecodable) OK
collection field (Set/List) emits invalid JSON ("type" key:value pair inside an array, wrong class name) crashes (NumberFormatException: For input string: "type")

Root mechanism: kotlinx defers each discriminator write until the next beginStructure, and value-class fields never open one; the pending tag then lands in whatever opens next (a sibling object, or a list context where it's parsed as an array index). Since typed-id value classes and collection params are bread-and-butter NavigationKey shapes, ALL_JSON_OBJECTS is effectively unusable until kotlinx fixes this.

POLYMORPHIC (the kotlinx default) writes discriminators exactly where polymorphic deserialization reads them (Instance.key, metadata values) and handles all of these shapes correctly. Old persisted states with extra type fields still decode (ignoreUnknownKeys); states corrupted by the leak are handled by the URL fallback.

Tests

  • HistoryStateSerializationTests — pins the POLYMORPHIC round-trip for the full problem shape (value class + enum set + value-class set), plus documenting tests that assert the upstream kotlinx failures still exist — when a kotlinx upgrade fixes them, those tests fail and the mode constraint can be revisited.
  • SavedStateSerializationTests — pins that the savedstate path (androidx encoder, ALL_OBJECTS) does not share the bug, so container restoration is unaffected.

Upstream issue: Kotlin/kotlinx.serialization#3022 (open since June 2025) — the collection-field failures here match its reported shape, and the repro in these tests adds the polymorphic-dispatch + value-class-field variants.

Changelog

Added under 3.0.0-beta03 (Unreleased): web history sync rewrite + serialization mode change.

🤖 Generated with Claude Code

isaac-udy and others added 19 commits June 11, 2026 11:49
…-push

The WebHistoryPlugin's sync loop had three bugs that made browser back
navigation jumpy on wasmJs:

1. Updates arriving while a sync job was in flight were silently
   dropped, desyncing the in-memory history mirror from the real
   session history — after which a single browser back could traverse
   multiple app screens. Lifecycle callbacks and popstate events now
   feed a single serial processor channel and are never dropped.

2. history.go() is asynchronous but was followed by delay(1) and a
   time-based listener-disable for echo suppression. The traversal's
   popstate echo usually arrived after suppression lifted and, when
   state comparison failed (e.g. during exit animations), was handled
   as a second user back. Traversals now await their popstate echo and
   consume it explicitly (with a timeout fallback).

3. Any state not present in the mirror was unconditionally pushState-ed,
   so full-backstack replacements (loading gate -> home, section
   truncate-and-open) accreted stale browser entries — making screens
   like a startup/loading destination reachable via back. The
   previously-dead isSubset helper is now wired up: a state is pushed
   only when the previous state is a prefix of it; otherwise the
   current entry is replaced.

Also: blind history.back() retry replaced with a bounded step-past
loop, popstate listener removed and processor cancelled on detach, and
the unused isNewState/collectInstructionIds helpers removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The serial processor loop introduced in the previous commit had no
exception isolation: one throwing sync (serializer, interceptor, path
computation) ended the for-loop permanently, after which browser back
updated the URL natively but the app never restored state. Each event
is now handled in its own try/catch (CancellationException rethrown)
and logged via EnroLog.error so the underlying failure is visible.
decodeState failures are also logged instead of silently ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
History entries persist on a browser tab across app builds, so a
serialization-format change (or a metadata value that doesn't round-trip
— e.g. kotlinx silently encodes polymorphic value classes as bare
literals it then cannot decode) leaves entries whose recorded state is
unreadable. Previously these no-opped: back updated the URL natively
but the app never moved.

Undecodable entries now fall back to resolving the entry's URL through
the controller's path bindings (single-entry, same semantics as a
cold-load deep link) and self-heal by overwriting the entry's state
with the freshly serialized equivalent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kotlinx encodes a polymorphic value class as its bare literal (no
discriminator can attach to a non-object body) and then cannot decode
it back — an asymmetric round-trip that silently produces persisted
state that can't restore. BoxedValueClassSerializer gives a value class
an object-shaped single-field envelope ({"type": fqn, "value": ...})
so the discriminator attaches and round-trips become symmetric; the
valueClassSubclass helper registers it in a polymorphic(Any) block.

The debug-mode metadata verification in EnroController now also does a
full encode->decode round-trip instead of only checking a serializer
exists, so any non-restorable type fails at metadata-set time with an
error naming the type and pointing at valueClassSubclass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e time

kotlinx can encode shapes it cannot decode, which writes history
entries that silently fail to restore. Decode-verify every state at
write time and log the failure with the full payload, so the offending
shape is diagnosable at the source instead of surfacing later as a
dead back button on a stale entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iagnose

When the serialized state fails round-trip verification, log the live
in-memory metadata (key names + value classes — the serialized JSON
mangles the offending entry, the in-memory map has the truth) and write
a metadata-stripped state instead. Instance ids and keys survive, which
is all back/forward restoration strictly requires, so browser back
keeps working even while an unserializable metadata value exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of unreadable web-history entries: with
ClassDiscriminatorMode.ALL_JSON_OBJECTS, kotlinx defers each
discriminator write until the next beginStructure — and a value-class
field never opens a structure. The pending discriminator for an inline
field (e.g. a typed-id value class on a NavigationKey) therefore leaked
into the next object that opened: Instance.metadata, which gained a
bogus {"type": "<value class fqn>"} entry and failed to decode with
'Expected JsonObject, but had JsonLiteral'. Every instance whose key
carried a value-class field was affected, even with empty metadata.

jsonConfiguration now uses the default POLYMORPHIC discriminator mode,
which writes discriminators exactly where polymorphic deserialization
reads them (Instance.key, metadata values) and nowhere else. Pinned by
HistoryStateSerializationTests.

WebHistoryPlugin's write-time verification is now a hard error instead
of degrading: a state that can't restore is never written (and metadata
is never stripped — silently losing result-channel wiring is worse than
failing loudly). The error carries the live in-memory metadata and the
serialized payload for diagnosis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ncoder

The discriminator leak that corrupted Instance.metadata is specific to
kotlinx's STREAMING encoder: under ClassDiscriminatorMode.ALL_JSON_OBJECTS
it defers each discriminator write until the next beginStructure, and a
value-class field never opens one — so under polymorphic dispatch
(Instance.key) the pending discriminator for an inline field leaks into
the next object that opens. The TREE encoder (encodeToJsonElement) does
not share the deferral and produces clean, decodable output for the
same configuration.

WebHistoryPlugin now encodes history state via the tree encoder,
transparently fixing the corruption for all consumers without changing
jsonConfiguration or any public API. The write-time round-trip
verification remains as a hard error (never degrade or strip data — a
state that can't restore is never written). The public jsonConfiguration
accessor documents the streaming-encoder caveat.

HistoryStateSerializationTests pin the tree-encode round-trip and
include a documenting test that fails when kotlinx fixes the streaming
encoder, signalling the workaround can be retired.

Also removes the BoxedValueClassSerializer/valueClassSubclass additions
and the metadata round-trip debug check from earlier on this branch —
superseded: the corruption was never caused by polymorphic value-class
metadata values, and nothing registers such values today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The savedstate path (rememberNavigationContainer, FlowResultManager,
enroSaver) uses androidx.savedstate serialization with ALL_OBJECTS — a
different encoder from kotlinx's streaming JSON. Verifies it does NOT
share the deferred-discriminator leak documented in
HistoryStateSerializationTests, so container restoration is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ALL_JSON_OBJECTS is unusable with kotlinx 1.11 for realistic
NavigationKey shapes under polymorphic dispatch — BOTH encoders fail:

- STREAMING: a value-class field's deferred discriminator leaks into
  the next-opened object (corrupting Instance.metadata so it can't
  decode), and collection fields produce outright INVALID JSON (a
  'type' key:value pair inside an array, with the wrong class name).
- TREE (encodeToJsonElement, the previous workaround): collection
  fields crash the encoder with NumberFormatException — the deferred
  discriminator tag is applied inside a list context where it's parsed
  as an array index. Surfaced by any destination with Set/List fields
  (e.g. an entity-picker popup with allowedTypes/excludeIds).

POLYMORPHIC mode (the kotlinx default) writes discriminators exactly
where polymorphic deserialization reads them (Instance.key, metadata
values) and handles all of these shapes correctly.

WebHistoryPlugin returns to plain encodeToString (the tree-encode
workaround is obsolete and was itself broken for collection fields),
keeping the write-time round-trip verification as a hard error.
HistoryStateSerializationTests pins the POLYMORPHIC round-trip for the
full problem shape (value class + enum set + value-class set) and keeps
documenting tests asserting both ALL_JSON_OBJECTS failures, so a
kotlinx upgrade that fixes them is detected and the mode can be
revisited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3.0.0-beta02 has been released; these changes land in the next version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the androidHostTest target, the Bundle-backed SavedState
implementation is not representative of a real device: ANY polymorphic
Instance round-trip through encodeToSavedState/decodeFromSavedState
fails there ('No valid saved state was found for the key, including
keys with no value-class fields at all (verified with a plain-String
control key). The serialization logic the test pins is common code;
desktop's Map-backed SavedState exercises it without the
unrepresentative host-Bundle layer. Device-faithful Android coverage
would need an instrumented test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Android host-test target compiles commonTest against android.jar
stubs, and isReturnDefaultValues makes stubbed framework methods return
defaults silently — savedstate's Bundle round-trip 'succeeds' on write
and returns null on read ('No valid saved state was found for the key
...'). Any polymorphic Instance round-trip through
encodeToSavedState/decodeFromSavedState failed this way on the host
target, including keys with no value-class fields.

Adds dev.enro.test.platform.RobolectricHostTest, an expect/actual base
class that runs the test under RobolectricTestRunner on the Android
host target (no-op everywhere else), with Robolectric on the host-test
classpath. SavedStateSerializationTests extends it and now passes on
testAndroidHostTest with a functional Bundle — also confirming that
real-device container state restoration round-trips polymorphic
instances correctly.

The SceneHarnessSmokeTest/SceneIntegrationTests/BackstackSavedStateTests
exclusions are left as-is; they may be removable with the same pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ci.yml gains a concurrency group keyed by PR number (falling back to
ref): a new push to a PR cancels the in-flight run for the superseded
commit. Pushes to main are never cancelled, so every main commit keeps
its complete CI record.

The changelog now stacks changes under a standing '## Unreleased'
header. The updateVersion task stamps that header with the version and
date at release time, inserts a fresh empty '## Unreleased' above it,
and writes the released section's body to build/release-notes.md —
failing before any file is written when the Unreleased section is
empty. release.yml commits CHANGELOG.md alongside version.properties
and attaches the extracted notes to the GitHub release via
'gh release create --notes-file' (the 'changes' input becomes an
optional prefix). Also modernises the workflow's actions: checkout@v4,
setup-java@v4, gradle/actions/setup-gradle@v4, add-and-commit@v9, and
the archived create-release@v1 replaced with the gh CLI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…source

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@isaac-udy isaac-udy merged commit f1aee74 into main Jun 11, 2026
@isaac-udy isaac-udy deleted the fix/web-history-sync branch June 11, 2026 05:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant